fix(rekey): preserve every durable main page - #19
Merged
farhan-syah merged 2 commits intoJul 26, 2026
Merged
Conversation
Re-encrypt durable free-list entries and residual copy-on-write pages before retiring the source epoch. Add regression coverage for free-list integrity, pinned reader snapshots, and monotonic epoch validation.
Rekey previously re-sealed main.db by scanning every allocated page between the reserved header and the allocation cursor, which skipped the catalog until its own commit. It also discarded the pages each catalog copy-on-write rewrite superseded instead of folding them into the free list, leaking them permanently once the source epoch was retired. Replace the residual-page scan with a bounded, deduplicating walk of the live catalog tree, and route every rekey-time catalog rewrite's freed pages through the durable free list before recording the new allocation cursor. Bound the memory a rekey walk pins by flushing the buffer pool mid-walk once the dirty set reaches the configured budget, since dirty cache entries are never evicted.
farhan-syah
force-pushed
the
codex/upstream/rekey-state-integrity
branch
from
July 26, 2026 22:43
d50192f to
3287ab0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Completes the main-database side of an online rekey before the source epoch is
retired, and stops the rekey protocol abandoning the pages its own transitions
supersede.
Three categories of durable page were still sealed under the source epoch when
rekey_dbreturned:write through; and
root and no free list.
Ordinary live-root reads looked healthy, because none of those pages is reached
from a live root. A physical integrity walk — which authenticates every non-zero
page in
main.db— failed after reopen, since successful retirement had removedthe only key that could authenticate them.
No public API, persisted format, command-line interface, feature flag, or
dependency changes.
The catalog is the root that cannot be re-sealed early
Every other reader-visible root is re-sealed before the target header is
published. The catalog is the exception: until that header is durable it must
stay source-readable, because it carries the rekey intent that an open able to
verify only the stale A/B side uses to admit recovery.
So it is re-sealed after publication instead, while both keys are still
installed — using the same bounded, deduplicating traversal the data tree uses.
Work is proportional to unique reachable catalog pages, not to the size of the
file.
This was easy to miss because the default retention policy hides it. With
history retained, the newest retained row names the live catalog root, so the
retained-root walk covers the catalog incidentally. With retention disabled,
only the pages a transition writes through get re-sealed, and everything else in
a multi-page catalog stays under an epoch that retirement then destroys.
Superseded pages are reclaimed, not abandoned
commit_rekey_catalog_roothad no freed-page parameter, so each durable stage —intent, header publication, main-done, segments-pending, segment progress rows,
and the final clear — abandoned the catalog pages its copy-on-write rewrite
superseded. Unreachable from every root, absent from the free list, and once the
source epoch retired, unauthenticatable. The leak grew with every rekey.
Every rekey-time catalog rewrite now routes its leftovers through the durable
free list, exactly as ordinary commits do. Entries carry the current commit id,
so the reclamation floor still withholds any page a live reader or retained
history root can still name; chain pages carry the id that marks writer-only
metadata immediately recyclable, matching the ordinary commit path.
Fixing the accounting is also what makes the rest cheap: once the superseded
pages are on the free list, the free-list pass re-seals them, and no scan of the
whole file is needed to find them.
Free-list pages
The free-list traversal previously decoded the current chain but re-sealed only
the pages storing the chain records. Those records also name reusable pages
whose envelopes can represent any legal
main.dbpage kind, and they areintentionally unreachable from live roots.
They are re-sealed rather than cleared. A page on the free list can still be
pinned below the reclamation floor by a live reader, so zeroing it would corrupt
a snapshot that is still legally readable.
The page kind comes from the cleartext Format-A header so the caller can select
the AAD binding, but the pager still authenticates the full envelope under that
same kind before marking it dirty. A tampered kind byte cannot smuggle a page
into another role — it fails the tag.
Bounded memory
Dirty pages are never evicted, so a rekey that re-seals a whole database before
a single flush would pin the entire decrypted store in the buffer pool no matter
what budget the caller configured.
rewrite_page_under_current_epochnow flusheswhenever the dirty set reaches that budget.
Flushing mid-walk is safe and idempotent: the active epoch is already the target,
so a page re-read after being re-sealed opens under the same key, and a crash
resumes from the durable intent with both keys installed.
Ordering
The catalog and free-list passes run after the target-authenticated header is
durable but before the intent advances from
HeaderTargetPublishedtoMainDone:making them idempotent;
MainDoneis published; andonly supersede target-epoch pages.
Regression coverage
tests/rekey_basic.rs. Each new test was confirmed to fail against theproduction change it pins.
rekey_reseals_a_catalog_larger_than_one_page— builds a catalog spanningseveral pages with retention disabled, rekeys, reopens, and runs the full deep
physical integrity walk. Stubbing out the catalog traversal makes it fail.
rekey_leaves_no_leaked_pages— rekeys three times in a row and asserts onorphan_page_idsdirectly, becauseDeepWalkReport::is_clean()deliberatelyexcludes orphans and so cannot see a leak. Without freed-page accounting it
reports one orphan per rekey plus eleven authentication failures.
rekey_preserves_durable_free_list_across_reopen— deletes most records withretention disabled, proves the durable free list is populated, rekeys, reopens
using only the live transition state, and walks the file.
rekey_preserves_preexisting_main_reader_snapshot_after_cache_evict— provesa reader opened before the rekey keeps its pinned logical snapshot after clean
cache entries are evicted, so the expanded physical rewrite leaves epoch leases
and retained snapshots alone.
rekey_rejects_non_advancing_epoch— a non-advancing epoch is rejected withoutpersisting an intent or poisoning an otherwise healthy store.
The existing segment-reconciliation failure case is already covered by
rekey_segment_reconciliation_failure_poisoned_handle_reopens_with_readable_segmentin
tests/durability/unpublished_commit.rs; it passes unchanged and is notduplicated here.
Verification
On this branch, rebased onto
main:cargo nextest run --all-features --no-fail-fast: 536 passed, 5 skipped;PAGEDB_INVARIANT_CHECKS=1: 536 passed, 5 skipped;cargo fmt --all --check: pass;cargo clippy --all-targets --all-features -- -D warnings: pass;cargo check --no-default-features: pass;cargo check --target wasm32-unknown-unknown --features opfs --lib: pass;cargo doc --no-deps --all-features: no warnings.Review focus
free-list passes?
HeaderTargetPublishedsafely repeat them with both epoch keysinstalled?
transition superseded?
readers pinned below the reclamation floor?